weakself

Optimize SwiftUI List View Updates

In this article we are going to take a quick look at how SwiftUI updates a list view when the State changes and a way to improve it.

Set up

To demostrate the topic we are going to create a very simple sample app. A view will display a list of users and when we tap on a cell, the tap count increases.

SwiftUI view updates sample app

Our user is a simple struct

struct User: Identifiable {
    let id = UUID()
    let name: String
    var taps = 0
}

The UsersView holds the list of users and displays a UserCell for each user

struct UsersView: View {
    @State private var users = [
        User(name: "Ben"),
        User(name: "David"),
        User(name: "Tom")
    ]
    
    var body: some View {
        let _ = Self._printChanges()
        NavigationView {
            List {
                ForEach($users) { $user in
                    UserCell(user: $user)
                }
            }
            .listStyle(.plain)
            .navigationTitle("Users")
        }
    }
}

struct UserCell: View {
    @Binding var user: User
    
    var body: some View {
        let _ = Self._printChanges()
        Button {
            user.taps += 1
        } label: {
            HStack {
                Text(user.name)
                Spacer()
                Text("\(user.taps)")
                    .foregroundStyle(.secondary)
            }
            .font(.subheadline)
        }
    }
}
Note: We add the Self._printChanges() call to see when the views body is called and recomputed.

When we run the sample app, we notice that each time we tap on a cell. The entire view and all the cells are recomputed

UsersView: @self, @identity, _users changed.
UserCell: @self, @identity, _user changed.
UserCell: @self, @identity, _user changed.
UserCell: @self, @identity, _user changed.

This is because everytime we update a value of a user in the users array, the entire users array is marked as updated. This is obviously not ideal, as only one cell was changed.

The trick to improve this, is to make the User model an ObservableObject, mark the values we want to get notified with the @Published keyword and to observe the changes in the cell.

final class User: Identifiable, ObservableObject {
    let id = UUID()
    let name: String
    @Published var taps = 0
    
    init(name: String) {
        self.name = name
    }
}

The UsersView stays almost the same

struct UsersView: View {
    @State private var users = [
        User(name: "Ben"),
        User(name: "David"),
        User(name: "Tom")
    ]
    
    var body: some View {
        let _ = Self._printChanges()
        NavigationView {
            List {
                ForEach(users) { user in
                    UserCell(user: user)
                }
            }
            .listStyle(.plain)
            .navigationTitle("Users")
        }
    }
}

And in our UserCell we observe the User changes

struct UserCell: View {
    @ObservedObject var user: User
    
    var body: some View {
        let _ = Self._printChanges()
        Button {
            user.taps += 1
        } label: {
            HStack {
                Text(user.name)
                Spacer()
                Text("\(user.taps)")
                    .foregroundStyle(.secondary)
            }
            .font(.subheadline)
        }
    }
}

With this change in place, we run the app and see that we only get one single log trace when we update a cell. Nice.

UserCell: _user changed.

Observable (iOS17+)

Adapting this to the Observable macro is straight forward.

@Observable
class User: Identifiable {
    let id = UUID()
    let name: String
    var taps = 0
    
    init(name: String) {
        self.name = name
    }
}

struct UserCell: View {
    @Bindable var user: User
    
    var body: some View {
        let _ = Self._printChanges()
        Button {
            user.taps += 1
        } label: {
            HStack {
                Text(user.name)
                Spacer()
                Text("\(user.taps)")
                    .foregroundStyle(.secondary)
            }
            .font(.subheadline)
        }
    }
}

When you run the app, you'll see the same result: only one cell is recomputed.

UserCell: @self changed.
Note: The UsersView stays exactly how it was.

As you see, the solution is quite simple and it helps to understand how the SwiftUI diffing and updates works.

If you want to get a more fine grain control on what subviews are recomputed, you need to mark the model containers as ObservableObject or @Observable and observe the idividual changes. Moreover this also enforces the use of classes over structs to hold the model data.

Does this also happen if we don't use a ForEach loop?

Let's take the first approach where we use a simple User struct and show three cells in a VStack

struct User {
    let name: String
    var taps = 0
}

struct UsersView: View {
    @State private var users = [
        User(name: "Ben"),
        User(name: "David"),
        User(name: "Tom")
    ]
    
    var body: some View {
        let _ = Self._printChanges()
        NavigationView {
            VStack {
                UserCell(user: $users[0])
                UserCell(user: $users[1])
                UserCell(user: $users[2])
                Spacer()
            }
            .buttonStyle(.bordered)
            .foregroundStyle(.primary)
            .padding()
            .navigationTitle("Users")
        }
    }
}

If we run the app, we see that the outcome is exactly the same as before. Each time we tap on a cell, the main view and each cell is recomputed.

UsersView: @self, @identity, _users changed.
UserCell: @self, @identity, _user changed.
UserCell: @self, @identity, _user changed.
UserCell: @self, @identity, _user changed.

And if don't use an array?

Now let's add a small twist to the example: instead of using an array lets create a Users struct that holds three users and use this instead.

struct Users {
    var user0: User
    var user1: User
    var user2: User
}

struct UsersView: View {
    @State private var users = Users(
        user0: User(name: "Ben"),
        user1: User(name: "David"),
        user2: User(name: "Tom")
    )
    
    var body: some View {
        let _ = Self._printChanges()
        NavigationView {
            VStack {
                UserCell(user: $users.user0)
                UserCell(user: $users.user1)
                UserCell(user: $users.user2)
                Spacer()
            }
            .buttonStyle(.bordered)
            .foregroundStyle(.primary)
            .padding()
            .navigationTitle("Users")
        }
    }
}

The output is interesting: when we tap on a cell, each cell is recomputed, but the main view is not

UserCell: _user changed.
UserCell: _user changed.
UserCell: _user changed.

Let's take it one step further: instead of having the users model in a State variable in the view, let's move it to an intermediate ObservableObject.

class UsersViewModel: ObservableObject {
    @Published var users = Users(
        user0: User(name: "Ben"),
        user1: User(name: "David"),
        user2: User(name: "Tom")
    )
}

struct UsersView: View {
    @StateObject private var model = UsersViewModel()
    
    var body: some View {
        let _ = Self._printChanges()
        NavigationView {
            VStack {
                UserCell(user: $model.users.user0)
                UserCell(user: $model.users.user1)
                UserCell(user: $model.users.user2)
                Spacer()
            }
            .buttonStyle(.bordered)
            .foregroundStyle(.primary)
            .padding()
            .navigationTitle("Users")
        }
    }
}

Running the app you will notice an intersting result: when we tap on one cell, the container view and only one cell is recomputed.

UsersView: _model changed.
UserCell: @self, _user changed.

How can we optimize this?

Now that we have seen all this different options, let's have a look at a few possible solutions to optimize it. Our goal is that only the cell that is updated gets recomputed.

  1. Have three separated State variables, each with one user
struct User: Equatable {
    let name: String
    var taps = 0
}

struct UsersView: View {
    @State var user0 = User(name: "Ben")
    @State var user1 = User(name: "David")
    @State var user2 = User(name: "Tom")
    
    var body: some View {
        let _ = Self._printChanges()
        NavigationView {
            VStack {
                UserCell(user: $user0)
                UserCell(user: $user1)
                UserCell(user: $user2)
                Spacer()
            }
            .buttonStyle(.bordered)
            .foregroundStyle(.primary)
            .padding()
            .navigationTitle("Users")
        }
    }
}
  1. A second possible option, is the one we mentioned at the begininning: make the User model Observable and observe the changes in the cell.
@Observable
class User {
    let name: String
    var taps = 0
    
    init(name: String) {
        self.name = name
    }
}

struct UserCell: View {
    @Bindable var user: User
    
    var body: some View {
        let _ = Self._printChanges()
        Button {
            user.taps += 1
        } label: {
            HStack {
                Text(user.name)
                Spacer()
                Text("\(user.taps)")
                    .foregroundStyle(.secondary)
            }
            .font(.subheadline)
        }
    }
}

struct UsersView: View {
    @State private var users = Users(
        user0: User(name: "Ben"),
        user1: User(name: "David"),
        user2: User(name: "Tom")
    )
    
    var body: some View {
        let _ = Self._printChanges()
        NavigationView {
            VStack {
                UserCell(user: users.user0)
                UserCell(user: users.user1)
                UserCell(user: users.user2)
                Spacer()
            }
            .buttonStyle(.bordered)
            .foregroundStyle(.primary)
            .padding()
            .navigationTitle("Users")
        }
    }
}

What happens if we introduce a view model?

Well, as you have seen in the previous section, when we introduced an intermediate ObservableObject, only one cell was recomputed, but also the main container view.

The solution is the same mentioned before: we make the User model Observable and observe the individual changes.

@Observable
class User {
    let name: String
    var taps = 0
    
    init(name: String) {
        self.name = name
    }
}

@Observable
class UsersViewModel {
    var users = Users(
        user0: User(name: "Ben"),
        user1: User(name: "David"),
        user2: User(name: "Tom")
    )
}

struct UsersView: View {
    @State private var model = UsersViewModel()
    
    var body: some View {
        let _ = Self._printChanges()
        NavigationView {
            VStack {
                UserCell(user: model.users.user0)
                UserCell(user: model.users.user1)
                UserCell(user: model.users.user2)
                Spacer()
            }
            .buttonStyle(.bordered)
            .foregroundStyle(.primary)
            .padding()
            .navigationTitle("Users")
        }
    }
}

With this in place, you get the expected result where only one cell is recomputed.

UserCell: @dependencies changed.

Learnings

As you can see, it's important to take some time to evaluate your apps architecture, model types and what has to be Observable and what not. For very simple views it might not matter, but for more complex composed views it can make a huge difference.

Tagged with: